# 16. 面试问题
# 1. css文件放在首部还是底部
- CSS 不会阻塞 DOM 的解析,但会阻塞 DOM 渲染。如果不阻塞DOM渲染用户体验可谓极差,可能会渲染多次,而且渲染是有成本的。因此,基于性能与用户体验的考虑,浏览器会尽量减少渲染的次数,CSS顺理成章地阻塞页面渲染。
- JS 阻塞 DOM 解析,但浏览器会"偷看"DOM,预先下载相关资源。
- 浏览器遇到
<script>且没有defer或async属性的标签时,会触发页面渲染,因而如果前面CSS资源尚未加载完毕时,浏览器会等待它加载完毕在执行脚本。因为js脚本中可能会获取计算样式,需要等待css加载后执行。defer被设定用来通知浏览器该脚本将在文档完成解析后,触发DOMContentLoaded事件前执行。
所以,你现在明白为何<script>最好放底部,<link>最好放头部,如果头部同时有<script>与<link>的情况下,最好将<script>放在<link>上面了吗?
放在底部可能会造成以下问题:
- 可能造成js计算样式不对
- 阻塞DOM解析
# 提高性能的九个技巧
第一条,DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。
第二条,如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次用到的时候,浏览器又要重排。
第三条,不要一条条地改变样式,而要通过改变class,或者csstext属性,一次性地改变样式。
// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top = top + "px";
// good
el.className += " theclassname";
// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
2
3
4
5
6
7
8
9
10
11
第四条,尽量使用离线DOM,而不是真实的网面DOM,来改变元素样式。比如,操作Document Fragment对象,完成后再把这个对象加入DOM。再比如,使用 cloneNode() 方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点。
第五条,先将元素设为display: none(需要1次重排和重绘),然后对这个节点进行100次操作,最后再恢复显示(需要1次重排和重绘)。这样一来,你就用两次重新渲染,取代了可能高达100次的重新渲染。
第六条,position属性为absolute或fixed的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响。
第七条,只在必要的时候,才将元素的display属性为可见,因为不可见的元素不影响重排和重绘。另外,visibility : hidden的元素只对重绘有影响,不影响重排。
第八条,使用虚拟DOM的脚本库,比如React等。
第九条,使用 window.requestAnimationFrame() 这两个方法调节重新渲染。
https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html (opens new window) https://juejin.im/post/59c60691518825396f4f71a1#heading-2 (opens new window)
# 2. translate会造成重绘么?
left/top/margin 之类的属性会影响到元素在文档中的布局,当对布局(layout)进行动画时,该元素的布局改变可能会影响到其他元素在文档中的位置,就导致了所有被影响到的元素都要进行重新布局,浏览器需要为整个层进行重绘并重新上传到 GPU,造成了极大的性能开销。
transform 属于合成属性(composite property),对合成属性进行 transition/animation 动画将会创建一个合成层(composite layer),这使得被动画元素在一个独立的层中进行动画。通常情况下,浏览器会将一个层的内容先绘制进一个位图中,然后再作为纹理(texture)上传到 GPU,只要该层的内容不发生改变,就没必要进行重绘(repaint),浏览器会通过重新复合(recomposite)来形成一个新的帧。
层创立的条件如下:
- 3D 或透视变换 CSS 属性
- 使用加速视频解码的
<video> - 元素拥有 3D (WebGL) 上下文或加速的 2D 上下文的
<canvas>元素 - 复合插件(如 Flash)进行 opacity/transform 动画的元素
- 拥有加速 CSS filters 的元素
- 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)
# 总结
- 对布局属性进行动画,浏览器需要为每一帧进行重绘并上传到 GPU 中
- 对合成属性进行动画,浏览器会为元素创建一个独立的复合层,当元素内容没有发生改变,该层就不会被重绘,浏览器会通过重新复合来创建动画帧。
链接:https://www.zhihu.com/question/33629083/answer/57062375 (opens new window)
# 3. 为什么要减少HTTP请求
好处:
- 减少http请求头的数据量
- 减少http连接的开销
- 会显著增加浏览器和服务器的网络资源消耗
坏处:
- 多个请求放到一个请求中,只要TCP丢了一个包,TCP自然会重传,需要T1时间,就会造成页面加载时间增加T1
- 缓存失效率就越高,浏览器缓存利用率相对偏低。
推荐2-6个合并
https://blog.csdn.net/chenchun91/article/details/52207008 (opens new window)
# 4. 垃圾回收算法优缺点
# 引用计数法
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计算器的值 为0,就说明对象A没有引用了,可以被回收。
优点 :
1、可即刻回收垃圾。
2、在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报outofmember错误。
3、区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点 :
1、每次对象被引用时,都需要去更新计数器,有一点时间开销。
2、浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
3、无法解决循环引用问题。(最大的缺点)
# 标记清除算法
将垃圾回收分为2个阶段,分别是标记和清除。
标记 :从根节点开始标记引用的对象。
清除 :未被标记引用的对象就是垃圾对象,可以被清理。
优点
标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。
缺点:
1、效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用
而言这个体验是非常差的。
2、通过标记清除算法清理出来的内容,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。
# 你觉得你在开发中遇到的最难的点是什么?如何解决的。
S:Situation 当时做这项工作或项目是什么背景? 因为之前只有一套测试环境,多人开发不同需求在一套环境中不够用,所以需要将一套测试环境变为多套,以便不同需求在不同测试环境中开发测试,解除多个需求在一套测试环境中耦合的情况。
T:Task 当时你接到的任务是什么,尽可能详细描述。 实现工单管理项目测试环境的多set的建立,就是说不同的主号可以设置不同的set环境,比如set1~10其中一个,以便开发不同需求可以使用不同set环境。
A:Action 你是怎么思考的,采取了什么策略和行动,这一步很能体现你处理问题的能力、逻辑思维、沟通和策略思考。
接到任务后,分析如何实现,其实就是打包部署时将前端资源存放在不同的set文件夹下,访问页面获取资源时根据主号的不同set从文件夹中获取。
部署时我们是使用coding流水线部署的,所以需要修改流水线的配置,启动时传两个数字参数env和set,env表示环境,set表示set几,执行流水线时会选择不同的env和set,往cos发送资源时会多拼一层路径set_2_8,这样资源就存在服务器不同的set文件夹下了,其实就是比之前多一层文件夹。
然后获取时,首先我们项目是需要先登录的,登录后可以从cookie中获取到主号,访问资源时在nginx中匹配到对应的资源路径,然后查找cookie中是否存在qidian_env_set,如果存在直接拼接在路径上,获取到对应set的资源,否则使用lua脚本调接口获取到对应的env和set,拼接到路径上,并保存在cookie中。
这样就实现了多套测试环境。
R:Review 事情结束以后你有什么收获和反思,旨在看你是不是一个会反思总结的人,反思总结能力在职场上非常重要。 做完之后学会了lua脚本语言以及在nginx的应用。反思:虽然实现了多set,但是之后还是会遇到很多问题,比如查找不同set的账号很困难,以及不同set有哪些需求正在使用。之后也需要做好账号和需求在不同set的记录工作,防止后面更加混乱。
# 最难的项目是什么?
保定地质灾害地图系统,实现了携带北斗导航的人车飞机轮船在地图上实时位置的展示以及历史轨迹的展示,接收报警功能,保障了出野外人员的安全。
难点是临时接手,也没文档,是angularjs实现的,之前了解过一些但是没实战过,所以挑战很大。还有代码都是写在一个js文件中的,很难阅读,并且不同业务功能都是混合在一起,导致很难查看和修改。
语言问题通过结合业务代码查找文档,不会的地方通过百度、问同事基本两天就可以修改功能了。然后就是重构代码,先将公共组件、公共函数、css、图片等存放在不同的文件夹中,然后将不同业务代码集中在一块,解决业务之间的耦合,做好注释,每块业务代码保存在不同的js中,后面哪块功能遇到问题只需要修改对应的js就行。
先定位这个项目(一句话背下来)
这是一个坐席 AI 智能助手前端 SDK:它不是普通页面,而是用 Vue 3 写的一个可被第三方呼叫中心系统动态嵌入的 JS SDK。通话中通过 WebSocket 接收实时 ASR 字幕、AI 通话小结、实时质检、推荐话术、声纹识别、流程导航等多路消息,以浮层 UI 的形式叠加在宿主系统上,支持 5 种语言。
面试官问"最难点"时,首选下面第 1 个,备选第 2 个,讲一个主难点 + 提一嘴另一个,显得有深度。
# 难点一(推荐主选):把整个 Vue 应用做成"可嵌入任意第三方系统的 SDK"
S(背景)
常规项目页面自成一摊,但这里要求:别人一个 <script> 引入,调 sdk.init({el, serviceUrl}) 就能在他们自己的系统里出现我们的 AI 助手 UI,而且要能 login / logout / destroy,还要订阅我们的内部事件(connected、summary、langChange)。本质是把一个 Vue 3 应用"活体"塞进别人的页面里。
T(难点)
生命周期管理:SDK 内部用 createApp(App) 动态挂载,可能挂到宿主指定节点,也可能自己 createElement('div') 创建 ts_assist_ai_sdk 容器;destroy() 时必须 unmount() 再 remove() 掉 DOM,否则宿主页面会内存泄漏、事件残留。
样式隔离:SDK 的 CSS 既不能污染宿主页面,也不能被宿主页面样式干扰。靠 vite-plugin-css-injected-by-js 把样式打进 JS、运行时注入,并统一加作用域前缀。
API 面设计:要设计一套稳定、同步/异步混合的对宿主接口(init / login / logout / destroy + 事件订阅),内部用 mitt 事件总线做桥接,宿主和 SDK 内部完全解耦。
资源冲突:z-index 不能写死(和宿主弹窗打架),用 store 里 getNextZIndex() 全局递增;baseURL 也要支持 init({serviceUrl}) 动态切换。
A(方案)
SDK 入口用原型方法封装(assistSdk.prototype.init/login/logout/destroy),init 里加了防重复挂载的守卫 if (this._vm && this._vm.$el) return;,避免宿主重复初始化导致双实例。 组件/指令/全局属性统一通过 setupGlobalProto() 注册(app.component('TsIcon')、app.directive('verbatim')、globalProperties.$ajax 等),配 unplugin-auto-import + unplugin-vue-components 自动引入,减小对宿主全局环境的侵入。 对外只暴露白名单事件(['login','logout','langChange','connected','summary']),内部 bus.emit,宿主订阅回调,做到内外隔离。 R(结果 / 可说的沉淀)
做到"一行代码接入、调用即用、销毁即清",多个不同风格的宿主系统接入零样式冲突、零内存泄漏。 沉淀出 SDK 化项目的通用套路:动态挂载 + 生命周期守卫 + 事件总线桥接 + 样式注入隔离 + 全局 zIndex 调度。 追问预案:"destroy 后为什么不会内存泄漏?" → 答:app.unmount() 会卸载组件树、移除事件监听,同时手动 remove() 容器 DOM,并清空 _vm/_mountEl/_unmountInstance 引用,配合 beforeUnmount 里 removeEventListener(比如 toolbar 里的 mousedown 置顶监听)。
# 难点二(备选):WebSocket 实时链路的"可靠性"问题
S/T(背景 + 难点)
通话中所有 AI 能力(字幕、小结、质检、声纹)都靠一条 WebSocket 长连接推送。最大的坑是**"连接假死"**:onopen 已经触发、socket 显示连着,但后端其实已经断了(比如网络被切换、服务端超时),此时不推数据、也不触发 close,前端永远等不到,字幕会静默消失。
A(方案)
用 reconnecting-websocket 做断线自动重连,并监听 maxReconnectClose 兜底。
自建心跳 ping/pong 机制:连接建立后每 60s 发一次 {access_token, ping:'ping'},收到 'pong' 就清零计数器;连续 5 次没收到 pong 就主动 socket.reconnect(),把假死连接强行重建。
消息按 msg_type 统一分发(0 token 校验 / 3 ASR 字幕 / 4 小结 / 5 话术 / 13 质检 / 12 声纹 / 7 流程 / 9 工单),各自路由到对应模块,避免在组件里到处裸写 onmessage。
流式字幕的时序问题:ASR 是流式的,content_type=1(完整句,带 message_id)和 content_type=2(没说完的半句)混着来,前端要区分"替换当前半句"还是"落一条完整记录",并在挂断后重置状态。
R(结果)
解决"看着连着其实断了"的静默故障,通话全程字幕/质检不掉线。 追问预案很稳:"怎么判断假死?" → 心跳;"重连会不会丢消息?" → 断线期间靠 token 重连后后端补齐/前端按 message_id 去重。
# 加分项:实时字幕的"逐字显示"体验(可选第 3 个,适合 UI 向岗位)
我封装了一个 v-verbatim 指令:字幕逐字打印出现,内部维护队列 queueArr,新的半句来了不打断当前显示、排队播放;通过 bus.emit('isverbatim') 广播状态避免并发播放;超时(默认 5s)自动淡出。
可讲点:为什么用指令而非组件——因为要挂在文本节点上动态 innerHTML 逐字追加,且多个字幕条复用;队列 + 全局状态锁解决了"连续两条消息互相打断"的体验问题。